iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Modern Web

Three.js × WebGL 實戰:打造 HD-2D 像素地下城與即時光影系統系列 第 17 篇

Day 17:修復方向盲區!Three.js Sprite 著色器翻轉真相、四向朝向狀態機、火把動態換手與指數平滑運鏡

  • 分享至 

  • xImage
  •  

在 Day 16 實裝主角與鍵盤移動後,在瀏覽器實機測試時暴露出四個嚴重的視覺與手感缺陷:

  • 角色向左走永遠倒退滑步:按下 A 鍵向左走時,精靈依然面朝右側,即使在程式碼寫入 playerSprite.scale.x = -playerBaseScale 也完全無效。
  • 前後奔跑缺乏朝向仲裁:單純按 W 往前或 S 往後時,角色維持側向滑動,缺乏四向狀態判定。
  • 火把光暈穿透後背:向左奔跑時隨身火把位置固定在右側,導致火光穿透後腦勺,破壞立體透視。
  • 相機劇烈甩動與滯後:原版相機固定係數 lerp(..., 0.08) 缺乏時間差補償,相機滯後主角達 1.5 個地磚,起步與煞車時產生嚴重的眩暈甩盪感。

今天我們將深入 Three.js 著色器層面揭開 Sprite 無法水平縮放的真相,透過 UV 逆向採樣徹底解決轉向問題,重構四向移動狀態機與隨身火把動態換手機制,將角色身形放大至 3.8 倍霸氣比例,並導入時間獨立指數阻尼(Exponential Damping)打造緊湊平穩的追隨運鏡!

【Step 1:深入底層著色器!破解 THREE.Sprite 縮放翻轉失效的真相】

在常規的 3D 網格(THREE.Mesh)中,將 X 軸縮放設為負值(scale.x = -1)即可完成水平鏡像翻轉。然而,這套邏輯在 THREE.Sprite 上完全失效。
這並非 JavaScript 邏輯錯誤,而是 Three.js 內部 Sprite 頂點著色器機制所致。為了確保廣告看板(Billboard)面向攝影機時不變形,著色器在計算幾何尺寸時強制採用了歐幾里得向量長度函數:
image
在數學上,向量長度計算結果永遠為非負實數(絕對值)。因此,無論在 JavaScript 中將 playerSprite.scale.x 設為 -2.8 還是 -100,GPU 在頂點著色階段都會透過 length() 強制轉正為正值,導致角色永遠只能朝右跑。

正確解法:UV 貼圖座標逆向採樣

幾何變換無法達成負縮放,必須改由紋理映射(UV Mapping)著手:
image
透過將貼圖寬度設為負值,GPU 片段著色器會由右向左逆向讀取像素,在完全不更動 Sprite 幾何矩陣的前提下達成無損水平鏡像翻轉。

【Step 2:四向朝向狀態機與多軸優先級仲裁】

橫向卷軸素材包通常只具備側面視角的走動幀。當玩家單純按住 W / S 在走廊移動或進行斜向輸入時,系統需要明確的方向意圖仲裁:
image

【Step 3:身形比例提升至 3.8 與隨身火把左右換手】

// 依據反饋進一步放大角色至 3.8,使其威武挺拔,顯著高於兩層石牆、與最高殘破主牆等高
// 
const playerBaseScale = 3.8;
playerSprite.scale.set(playerBaseScale, playerBaseScale, 1);

// 建立主角群組並加入微光探險火把
const playerGroup = new THREE.Group();
playerGroup.add(playerSprite);

// :配合放大至 3.8 的角色身形,火把高度調升至 y=1.8 (胸口),左右手偏移距離擴大為 0.45,照明半徑調至 8.5

const playerLight = new THREE.PointLight('#ffb066', 2.3, 8.5, 1.2);
const torchOffsetX = 0.45; // 【DAY 17 新增】左右手持火把偏移量
playerLight.position.set(torchOffsetX, 1.8, 0.25);
playerGroup.add(playerLight);

// 初始化相機鎖定目標高度調高至新角色胸口高度 y=2.0 (原先 y=1.6)

cameraTarget.set(spawnPos.x, 2.0, spawnPos.z);

// 微調角色碰撞半徑為 0.34 (配合 3.8 巨大身形,且完美保留穿梭 1 格寬度窄門走廊的能力)
const playerCollisionRadius = 0.34;

【Step 4:實裝完整 updatePlayer 狀態與動畫控制】

將四向判定、UV 翻轉與動態換手整合至主角物理更新邏輯中:

// 記錄四向朝向狀態與面向左側旗標 (徹底解決 A 鍵向左走倒退滑步問題)
let currentDirection = 'right'; // 'left' | 'right' | 'up' | 'down'
let facingLeft = false;

function updatePlayer(dt) {
  let moveX = 0;
  let moveZ = 0;

  if (keys.w) moveZ -= 1;
  if (keys.s) moveZ += 1;
  if (keys.a) moveX -= 1;
  if (keys.d) moveX += 1;

  const isMoving = (moveX !== 0 || moveZ !== 0);

  if (isMoving) {
    const len = Math.hypot(moveX, moveZ);
    moveX /= len;
    moveZ /= len;

    // 1. 四向朝向狀態機判定 (依位移分量仲裁主導方向)
    if (Math.abs(moveX) >= Math.abs(moveZ)) {
      if (moveX < -0.01) {
        currentDirection = 'left';
        facingLeft = true;
      } else if (moveX > 0.01) {
        currentDirection = 'right';
        facingLeft = false;
      }
    } else {
      if (moveZ < -0.01) {
        currentDirection = 'up';
      } else if (moveZ > 0.01) {
        currentDirection = 'down';
      }
      // 縱向移動帶有水平微偏時同步更新面向
      if (moveX < -0.1) facingLeft = true;
      if (moveX > 0.1) facingLeft = false;
    }

    // 2. 隨身火把動態換手機制 (面向左側換至左手 x=-0.45,面向右側換至右手 x=+0.45)
    playerLight.position.x = facingLeft ? -torchOffsetX : torchOffsetX;

    const dx = moveX * playerSpeed * dt;
    const dz = moveZ * playerSpeed * dt;
    const currX = playerGroup.position.x;
    const currZ = playerGroup.position.z;

    // 碰撞檢查與滑牆移動 (Wall Sliding)
    if (!checkCollision(currX + dx, currZ + dz)) {
      playerGroup.position.x += dx;
      playerGroup.position.z += dz;
    } else {
      if (!checkCollision(currX + dx, currZ)) playerGroup.position.x += dx;
      if (!checkCollision(currX, currZ + dz)) playerGroup.position.z += dz;
    }

    // 切換為跑步動作貼圖
    if (currentAnim !== 'run') {
      currentAnim = 'run';
      currentFrame = 0;
      animTimer = 0;
      playerMat.map = runTex;
      playerMat.needsUpdate = true;
    }
  } else {
    // 待機時維持前次面向,火把同步維持正確手持側
    playerLight.position.x = facingLeft ? -torchOffsetX : torchOffsetX;

    if (currentAnim !== 'idle') {
      currentAnim = 'idle';
      currentFrame = 0;
      animTimer = 0;
      playerMat.map = idleTex;
      playerMat.needsUpdate = true;
    }
  }

  // 動畫幀輪播更新
  const frameDuration = currentAnim === 'run' ? 0.09 : 0.16;
  const totalFrames = currentAnim === 'run' ? RUN_COLS : IDLE_COLS;

  animTimer += dt;
  if (animTimer >= frameDuration) {
    animTimer = 0;
    currentFrame = (currentFrame + 1) % totalFrames;
  }

  // 透過 UV repeat.x 負值與 offset.x 偏移達成向左翻轉
  if (playerMat.map) {
    if (facingLeft) {
      playerMat.map.repeat.x = -1 / totalFrames;
      playerMat.map.offset.x = (currentFrame + 1) / totalFrames;
    } else {
      playerMat.map.repeat.x = 1 / totalFrames;
      playerMat.map.offset.x = currentFrame / totalFrames;
    }
  }

  // 維持精靈本體尺寸正向,縮放由 playerBaseScale 統一控制
  playerSprite.scale.x = playerBaseScale;
}

【Step 5:告別橡皮筋甩盪!時間獨立指數阻尼運鏡(Exponential Damping)】

先前跟隨運鏡存在兩項缺陷:

  • 相機一度鎖死:在替換目標高度時若漏掉 X 與 Z 軸更新,會導致相機焦點固定在出生點停滯不前。
  • 嚴重滯後與晃動:原版程式碼使用 THREE.MathUtils.lerp(..., 0.08) 且無幀率補償,相機嚴重滯後角色達 1.5 個地磚,起步與急停時產生劇烈擺盪。

改採時間獨立指數阻尼公式:

image
設定 $\text{camFollowSpeed} = 12.0$,相機每幀能以約 18% 的速度緊湊收斂目標差距,將滯後距離自 1.5 格大幅壓縮至 0.3 格以內,平穩鎖定角色的同時徹底消除擺盪眩暈感。

function animate() {
  requestAnimationFrame(animate);

  const dt = Math.min(clock.getDelta(), 0.1);
  const elapsedTime = clock.getElapsedTime();

  // 壁燈火光呼吸搖曳
  animatedLights.forEach(item => {
    const flicker = Math.sin(elapsedTime * item.speed + item.phase) * 0.35 +
                    Math.cos(elapsedTime * item.speed * 2.1 + item.phase) * 0.2;
    item.light.intensity = item.baseIntensity + flicker;
  });

  // 更新主角物理移動、狀態機與動畫
  updatePlayer(dt);

  // 相機動態追蹤主角 (修復鎖死問題 + 大幅減少晃動幅度)
  const camFollowSpeed = 12.0; // 緊湊平穩跟隨速度 (大幅消除大幅晃動)
  const camFactor = 1.0 - Math.exp(-camFollowSpeed * dt);

  cameraTarget.x += (playerGroup.position.x - cameraTarget.x) * camFactor;
  cameraTarget.z += (playerGroup.position.z - cameraTarget.z) * camFactor;
  cameraTarget.y = 2.0; // 鎖定巨大主角胸口核心高度

  camera.position.copy(cameraTarget).add(cameraOffset);
  camera.lookAt(cameraTarget);

  renderer.render(scene, camera);
}

【本日結語】
至此,Day 17 的所有技術盲區已全數解決。角色向左奔跑時透過 UV 逆向採樣精確轉身,手持火把在左右手之間切換自如,3.8 倍比例展現出與古堡高牆平分秋色的身形,相機以指數阻尼平穩跟隨,徹底告別了畫面晃動問題。
image
image
然而,眼前的世界雖然操控流暢,卻還缺少了那股讓玩家屏息的「電影級空氣感」——火把的火光缺乏溫暖朦朧的溢光漫射,遠處的殘垣也少了微縮模型特有的夢幻景深虛化。在接下來的 Day 18,我們將正式導入 Three.js 後處理管線(EffectComposer),實裝 Unreal Engine 級別的選擇性輝光(Unreal Bloom)、移軸微縮景深(Tilt-Shift Blur) 與 電影暗角(Vignette),為地宮披上一層如夢似幻的朦朧光暈,將畫面質感直接推升至商業大作水準!


上一篇
Day 16:主角降臨!2D 像素精靈 (THREE.Sprite)、手持動態火把與網格碰撞移動系統
系列文
Three.js × WebGL 實戰:打造 HD-2D 像素地下城與即時光影系統 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言